// ══════════════════════════════════════════════════════════════════════════════ // ADMIN PANEL MED FORBEDRET CROP FUNKTIONALITET // Til at tilføje til index.html efter AdminLogin komponenten // ══════════════════════════════════════════════════════════════════════════════ // ── Admin Panel ─────────────────────────────────────────────────────────────── function AdminPanel({ token, adminName, sections, onClose, onSectionsChanged, onLogout, toast, defaultSection, onReloadPosts }) { const [tab, setTab] = useState(defaultSection ? 'posts' : 'posts'); const [editPost, setEditPost] = useState(null); useEffect(() => { if (defaultSection) { setEditPost({ _new: true, section_id: defaultSection, title: '', body: '', status: 'published' }); } }, [defaultSection]); function handlePostSaved() { setEditPost(null); toast('Opslag gemt ✓'); if (onReloadPosts) onReloadPosts(); } function handlePostDeleted() { setEditPost(null); toast('Opslag slettet'); if (onReloadPosts) onReloadPosts(); } return (
e.stopPropagation()}> {editPost ? ( setEditPost(null)} toast={toast} /> ) : ( <>

Admin Panel

{adminName}
{tab === 'posts' && setEditPost(p)} onNew={() => setEditPost({ _new: true, section_id: sections[0]?.id, title: '', body: '', status: 'published' })} toast={toast}/>} {tab === 'sections' && } {tab === 'admins' && }
)}
); } // ── Posts tab ───────────────────────────────────────────────────────────────── function PostsTab({ token, sections, onEdit, onNew, toast }) { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { load(); }, []); async function load() { setLoading(true); const d = await adminApi({ action:'get-posts' }, token); setPosts(d.posts || []); setLoading(false); } if (loading) return
; return (

Alle opslag ({posts.length})

{posts.map(p => (
onEdit(p)}>
{p.title}
{sectionName(sections, p.section_id)} · {fmtDate(p.created_at)} · {p.image_count} billeder
{p.status === 'published' ? 'Publiceret' : 'Kladde'}
))} {posts.length === 0 && (
📝

Ingen opslag endnu

Klik "+ Nyt opslag" for at komme i gang.

)}
); } // ── Post Editor MED FORBEDRET CROP ──────────────────────────────────────────── function PostEditor({ post, token, sections, onSave, onDelete, onClose, toast }) { const [title, setTitle] = useState(post.title || ''); const [body, setBody] = useState(post.body || ''); const [secId, setSecId] = useState(post.section_id || sections[0]?.id || ''); const [status, setStatus] = useState(post.status || 'published'); const [images, setImages] = useState([]); const [busy, setBusy] = useState(false); const [postId, setPostId] = useState(post._new ? null : post.id); const fileRef = useRef(); // 🎨 FORBEDRET CROP STATE const [cropSrc, setCropSrc] = useState(null); const [cropImgId, setCropImgId] = useState(null); const [cropInst, setCropInst] = useState(null); const [cropBusy, setCropBusy] = useState(false); const [cropLoading, setCropLoading] = useState(false); const [cropAspect, setCropAspect] = useState('free'); const cropRef = useRef(); // Hent billeder til eksisterende opslag useEffect(() => { if (!post._new && post.id) { adminApi({ action:'get-post', id: post.id }, token).then(d => { if (d.images) setImages(d.images); }); } }, [post.id]); // 🎨 FORBEDRET: Cropper init med loading state useEffect(() => { if (!cropSrc) return; setCropLoading(true); let inst; const t = setTimeout(() => { if (!cropRef.current) { setCropLoading(false); return; } inst = new Cropper(cropRef.current, { viewMode: 1, autoCropArea: 1, movable: true, zoomable: true, rotatable: true, scalable: true, responsive: true, restore: true, checkOrientation: true, guides: true, center: true, highlight: true, background: false, autoCrop: true, }); // Vent på ready event cropRef.current.addEventListener('ready', () => { setCropInst(inst); setCropLoading(false); }); }, 80); return () => { clearTimeout(t); if (inst) inst.destroy(); setCropLoading(false); }; }, [cropSrc]); // 🎨 NYT: Keyboard shortcuts useEffect(() => { if (!cropSrc) return; const handleKey = (e) => { if (e.key === 'Escape') closeCrop(); if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) saveCrop(); if (e.key === 'r' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); cropInst?.rotate(90); } if (e.key === 'R' && e.shiftKey) { e.preventDefault(); cropInst?.rotate(-90); } if (e.key === 'f' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); const d = cropInst?.getImageData(); cropInst?.scaleX(d?.scaleX === -1 ? 1 : -1); } if (e.key === 'z' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); cropInst?.reset(); } }; window.addEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey); }, [cropSrc, cropInst]); // 🎨 FORBEDRET: Validation + error handling function openCropNew(file) { const MAX_SIZE = 10 * 1024 * 1024; if (file.size > MAX_SIZE) { toast(`Billedet er for stort (${(file.size/1024/1024).toFixed(1)}MB). Max 10MB.`, 'error'); return; } if (!file.type.startsWith('image/')) { toast('Kun billedfiler er tilladt (JPG, PNG, WebP)', 'error'); return; } const reader = new FileReader(); reader.onload = e => { setCropSrc(e.target.result); setCropImgId(null); setCropAspect('free'); }; reader.onerror = () => { toast('Kunne ikke læse billedet', 'error'); }; reader.readAsDataURL(file); } function openCropExisting(img) { setCropSrc(img.image_url); setCropImgId(img.id); setCropAspect('free'); } function closeCrop() { if (cropInst) { cropInst.destroy(); setCropInst(null); } setCropSrc(null); setCropImgId(null); setCropAspect('free'); } // 🎨 FORBEDRET: Intelligent compression + WebP fallback async function saveCrop() { if (!cropInst) return; let pid = postId; if (!pid && !cropImgId) { if (!title.trim()) { toast('Skriv en titel inden du uploader billede', 'error'); return; } setBusy(true); const d = await adminApi({ action:'create-post', title, body, section_id: secId, status }, token); setBusy(false); if (d.error) { toast(d.error, 'error'); return; } pid = d.id; setPostId(pid); } setCropBusy(true); try { const imgData = cropInst.getImageData(); const targetWidth = Math.min(imgData.naturalWidth, 1600); const canvas = cropInst.getCroppedCanvas({ width: targetWidth, maxHeight: 1600, imageSmoothingQuality: 'high', fillColor: '#fff' }); if (!canvas) { toast('Kunne ikke generere billede', 'error'); setCropBusy(false); return; } let imageData; let format = 'webp'; try { imageData = canvas.toDataURL('image/webp', 0.85); if (!imageData.startsWith('data:image/webp')) { throw new Error('WebP not supported'); } } catch (e) { format = 'jpeg'; imageData = canvas.toDataURL('image/jpeg', 0.82); } const sizeInMB = (imageData.length * 0.75) / 1024 / 1024; if (sizeInMB > 5) { toast(`Billedet er stort (${sizeInMB.toFixed(1)}MB). Overvej at beskære mere.`, 'warning'); } if (cropImgId) { const existing = images.find(i => i.id === cropImgId); const pid2 = postId || post.id; await adminApi({ action:'delete-image', id: cropImgId }, token); const d = await adminApi({ action: 'upload-image', post_id: pid2, imageData, caption: existing?.caption || '', sort_order: existing?.sort_order ?? 0, }, token); if (d.id || d.ok) { setImages(imgs => imgs.map(i => i.id === cropImgId ? { ...i, id: d.id, image_url: d.url } : i )); toast(`Billede opdateret ✓ (${format.toUpperCase()}, ${sizeInMB.toFixed(1)}MB)`); } else { toast(d.error || 'Upload fejlede', 'error'); } } else { const d = await adminApi({ action: 'upload-image', post_id: pid, imageData, caption: '', sort_order: images.length, }, token); if (d.id || d.ok) { setImages(imgs => [...imgs, { id: d.id, image_url: d.url, caption: '', sort_order: imgs.length, post_id: pid }]); toast(`Billede uploadet ✓ (${format.toUpperCase()}, ${sizeInMB.toFixed(1)}MB)`); } else { toast(d.error || 'Upload fejlede', 'error'); } } } catch(e) { toast('Fejl: ' + e.message, 'error'); } setCropBusy(false); closeCrop(); } async function save() { if (!title.trim()) return toast('Titel kræves', 'error'); setBusy(true); let pid = postId; if (post._new && !pid) { const d = await adminApi({ action:'create-post', title, body, section_id: secId, status }, token); if (d.error) { toast(d.error, 'error'); setBusy(false); return; } pid = d.id; setPostId(pid); for (const img of images) await adminApi({ action:'update-image', id: img.id, caption: img.caption, sort_order: img.sort_order, post_id: pid }, token); } else if (pid) { await adminApi({ action:'update-post', id: pid, title, body, section_id: secId, status }, token); } setBusy(false); onSave(); } async function doDelete() { if (!confirm('Slet dette opslag og alle tilhørende billeder?')) return; await adminApi({ action:'delete-post', id: postId }, token); onDelete(); } async function deleteImage(id) { if (!confirm('Slet dette billede?')) return; await adminApi({ action:'delete-image', id }, token); setImages(imgs => imgs.filter(i => i.id !== id)); toast('Billede slettet'); } async function updateCaption(id, caption) { setImages(imgs => imgs.map(i => i.id === id ? { ...i, caption } : i)); await adminApi({ action:'update-image', id, caption, sort_order: images.find(i=>i.id===id)?.sort_order || 0, post_id: postId }, token); } return ( <>

{post._new ? 'Nyt opslag' : 'Rediger opslag'}

{!post._new && }
fileRef.current.click()} onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('drag'); }} onDragLeave={e => e.currentTarget.classList.remove('drag')} onDrop={e => { e.preventDefault(); e.currentTarget.classList.remove('drag'); const f=e.dataTransfer.files[0]; if(f) openCropNew(f); }} >
📸

Klik eller træk et billede hertil

Beskær og roter inden upload · JPG, PNG, WEBP · Max 10MB

{ const f=e.target.files[0]; if(f) openCropNew(f); e.target.value=''; }}/> {images.length > 0 && (
{images.map(img => (
{img.caption}/
setImages(imgs => imgs.map(i => i.id === img.id ? {...i, caption: e.target.value} : i))} onBlur={e => updateCaption(img.id, e.target.value)}/>
))}
)}
setTitle(e.target.value)} placeholder="Opslagets titel…"/>